About
Overview
This prototype data explorer tool provides provisional weekly death statistics for England and Wales from the Office for National Statistics (2025 onwards).
A death occurrence is the date someone has died. A death registration is when that death is registered. The time it takes for a death to be registered can vary for multiple reasons. Currently, mortality statistics are registration-based.
An official dashboard is also available, and further information here.
Any feedback or enquiries can be directed to health.data@ons.gov.uk
Time Series (2025 onwards)
Plotly = require("https://cdn.plot.ly/plotly-2.27.0.min.js")
// `death_data` is provided by the earlier R chunk which we passed into OJS via `ojs_define()`.
// `transpose()` essentially turns a "column-oriented" table into a row-wise array of objects.
death_data_raw = transpose(death_data)
// parse dates and numbers
/* IMPORTANT: parse and normalise fields
- JavaScript treats spreadsheet values as strings by default
- so we convert the week ending to a Date, numeric fields to numbers, etc...
- this keeps the rest of the code simpler and avoids type bugs.
*/
death_data_parsed = death_data_raw.map(d => ({
series: d.series, //registrations
week_ending: new Date(d.week_ending_str), // date object for plotly
week_number: +d.week_number, // force number
area_of_usual_residence: d.area_of_usual_residence, // region name
sex: d.sex, // male /female
age_band: d.age_band,
age_order: +d.age_order, // ordering key from R
number_of_deaths: +d.number_of_deaths // once again force number
}))
// unique values
unique_series = ["Registrations", "Occurrences"]
unique_weeks = Array.from(new Set(death_data_parsed.map(d => d.week_number))).sort((a,b)=>a-b)
unique_regions = Array.from(new Set(death_data_parsed.map(d => d.area_of_usual_residence))).sort()
unique_sex = ["Male", "Female"]
unique_ages = ["0-14 years","15-44 years","45-64 years","65-74 years","75-84 years","85+ years"]
// colours - hex codes copied from ons website
color_primary = "#003C57"
color_secondary = "#a8bd3a"
color_accent = "#00a3a6"
color_warning = "#206095"
// logic to switch colour based on sex selection
current_sex_color = {
// if both are selected (length 2) or nothing selected (length 0), use default
if (selected_sex.length === 2 || selected_sex.length === 0) {
return color_primary;
}
// if only Male is selected
else if (selected_sex.includes("Male")) {
return "#206095";
}
// if only Female is selected
else {
return "#00a3a6";
}
}
// create the default state: automatically show latest week selected for bar charts.
// we compute the most recent week number and use it as the default selection.
// NOTE: This affects the bar charts (which read from `filtered_data` below).
latestWeek = d3.max(unique_weeks)
// built-in inputs
// these create reactive controls. When a user touches a filter, these controls change the plot.
// dependent cells re-run automatically
viewof selected_series = Inputs.radio(unique_series, { label: html`Data Type`, value: "Registrations" })
viewof selected_sex = Inputs.checkbox(unique_sex, { label: html`Sex`, value: unique_sex })
// default to the latest week ONLY
//viewof selected_weeks = Inputs.select(
// unique_weeks,
// { label: html`Week Numbers`, multiple: true, value: [latestWeek], size: 8 }
//)
viewof selected_weeks = Inputs.select(
unique_weeks,
// Remove {multiple: true, size: 8} from the options object
// Set the value directly to the single latestWeek number
{ label: html`Week Numbers`, value: latestWeek }
)
/* shared filtered data for bar charts
here we filter by:
- selected series (Registrations vs Occurrences),
- the currently selected week or weeks,
- selected sex (Male/Female).
The time-series chart does NOT use this because
that line needs to show all weeks by default.
*/
//filtered_data = death_data_parsed.filter(d =>
// d.series === selected_series &&
// selected_weeks.includes(d.week_number) &&
// selected_sex.includes(d.sex)
//)
filtered_data = death_data_parsed.filter(d =>
d.series === selected_series &&
// MODIFIED: Use strict equality (== or ===) because selected_weeks is a single number, not an array
d.week_number === selected_weeks &&
selected_sex.includes(d.sex)
)
// format a date as "17 Oct 2025" and ensure UK style
formatWeekEnding = date =>
date?.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }) ?? "–"
// for the chart titles find the latest week-ending date present in the current selection.
// if multiple weeks are selected, this picks the most recent to show in the title
selected_week_end_date =
(filtered_data.length
? d3.max(filtered_data, d => d.week_ending)
: null)
selected_week_title = `Week ending ${formatWeekEnding(selected_week_end_date)}`
// dynamic titles for selected sex
// Determine sex label for title
sexTitle = (() => {
if (selected_sex.length === 2 || selected_sex.length === 0) {
return "";
} else if (selected_sex.includes("Male")) {
return "(Male)";
} else {
return "(Female)";
}
})();
// label: timeseries-chart
{
// build a dataset that that IGNORES selected_weeks so TS always shows all weeks
const ts_rows = death_data_parsed.filter(d =>
d.series === selected_series &&
selected_sex.includes(d.sex)
);
// aggregate by week (sum over all selected sexes)
let ts_map = d3.rollup(
ts_rows,
v => d3.sum(v, d => d.number_of_deaths),
d => d.week_ending.toISOString()
);
let ts_array = Array.from(ts_map, ([date, deaths]) => ({ date: new Date(date), deaths }))
.sort((a,b) => a.date - b.date);
if (selected_series === "Occurrences" && ts_array.length > 0) ts_array = ts_array.slice(0, -1);
const trace = {
x: ts_array.map(d => d.date),
y: ts_array.map(d => d.deaths),
type: 'scatter',
mode: 'lines+markers',
name: selected_series,
marker: { size: 8, color: selected_series === "Registrations" ? current_sex_color : color_secondary },
line: { width: 4, color: selected_series === "Registrations" ? current_sex_color : color_secondary },
hovertemplate: 'Week ending: %{x|%d %b %Y}<br>Deaths: %{y:,.0f}<extra></extra>'
};
const div = (this && this.style) ? this : DOM.element('div');
// hard-stop any transient overflow inside the card
div.style.width = '100%';
div.style.height = '100%';
div.style.overflow = 'hidden';
// measure the container and pin the layout size to integer pixels
const rect = div.getBoundingClientRect();
const w = Math.floor(rect.width);
const h = Math.floor(rect.height);
const layout = {
title: { text: `<b>Weekly Deaths - ${selected_series} ${sexTitle}</b>`,
font: { size: 16, color: '#333', family: 'Open Sans, Arial, sans-serif' }, x: 0, xanchor: 'left' },
xaxis: { title: { text: 'Week ending', font: { size: 16 }, standoff: 70 }, showgrid: true, gridcolor: '#f0f0f0' },
yaxis: { title: { text: 'Number of Deaths', font: { size: 16 }, standoff: 100 }, showgrid: true, gridcolor: '#f0f0f0', tickformat: ',d' },
plot_bgcolor: '#ffffff', paper_bgcolor: '#ffffff',
margin: { l: 80, t: 60, r: 20, b: 40 },
autosize: false, // <— stop automatic size pass
width: w, // <— exact pixel width
height: h // <— exact pixel height
};
const config = { displayModeBar: false, responsive: true };
Plotly.react(div, [trace], layout, config);
// keep responsiveness: if the card resizes, update the plot size to the new integer pixels
if (!div._ro) {
div._ro = new ResizeObserver(entries => {
const cr = entries[0].contentRect;
Plotly.relayout(div, { width: Math.floor(cr.width), height: Math.floor(cr.height) });
});
div._ro.observe(div);
}
return div;
}Deaths by Region
regional_totals = d3.rollup(filtered_data, v => d3.sum(v, d => d.number_of_deaths), d => d.area_of_usual_residence)
regional_array = Array.from(regional_totals, ([region, deaths]) => ({ region, deaths }))
.sort((a, b) => b.deaths - a.deaths)
{
const trace = {
y: regional_array.map(d => d.region),
x: regional_array.map(d => d.deaths),
type: 'bar', orientation: 'h',
marker: { color: current_sex_color, line: { color: '#333', width: 1 } },
hovertemplate: '%{y}<br>Deaths: %{x:,.0f}<extra></extra>'
};
const div = (this && this.style) ? this : DOM.element('div');
div.style.width = '100%';
div.style.height = '100%';
div.style.overflow = 'hidden';
const rect = div.getBoundingClientRect();
const w = Math.floor(rect.width);
const h = Math.floor(rect.height);
const layout = {
title: { text: `<b>${selected_series} for ${selected_week_title} ${sexTitle} (Week ${selected_weeks})</b>`, x: 0, xanchor: 'left' },
xaxis: { title: { text: 'Number of Deaths', font: { size: 16 } }, showgrid: true, gridcolor: '#f0f0f0' },
yaxis: { title: '', automargin: false }, // <— pin margins to avoid relayout jumps
margin: { t: 40, b: 40, l: 150, r: 60 },
plot_bgcolor: '#ffffff', paper_bgcolor: '#ffffff',
autosize: false, width: w, height: h
};
const config = { displayModeBar: false, responsive: true };
Plotly.react(div, [trace], layout, config);
if (!div._ro) {
div._ro = new ResizeObserver(entries => {
const cr = entries[0].contentRect;
Plotly.relayout(div, { width: Math.floor(cr.width), height: Math.floor(cr.height) });
});
div._ro.observe(div);
}
return div;
}Deaths by Age and Sex
age_order_key = age => {
const m = /(\d+)/.exec(age || "");
return m ? +m[1] : Number.POSITIVE_INFINITY;
}
// determine the order of age bands on the Y axis.
// if a global list `unique_ages` exists (defined elsewhere), use that order;
// otherwise, derive the distinct set of age bands from the current data and
// sort them using the numeric key above (youngest first).
ordered_ages = (typeof unique_ages !== "undefined" && Array.isArray(unique_ages) && unique_ages.length)
? unique_ages.slice()
: Array.from(new Set((filtered_data || []).map(d => d.age_band))).sort((a,b) => age_order_key(a) - age_order_key(b))
// we aggregate deaths by age band and sex for the current selection.
// `filtered_data` already applies the filters (series, sex, and the selected week).
age_sex_pyramid_data = {
// defensive default: if filtered_data is missing then fall back to an empty array.
const rows = filtered_data || [];
// d3.rollup essentially groups rows first by age band, then by sex, and sums deaths
// the result is a nested map: Map(age_band -> Map(sex -> total_deaths))
const rolled = d3.rollup(rows, v => d3.sum(v, d => +(d.number_of_deaths || 0)), d => d.age_band, d => d.sex);
// now we populate two arrays for plotly:
// - `male` with negative values (so bars appear on the left),
// - `female` with positive values (bars to the right)
// here also "track" totals to compute percentages in tooltips
const displayLabel = age => age.replace('-', ' to ');
const order = ordered_ages;
const male = []; const female = [];
let total = 0; const totalBySex = new Map([["Male",0],["Female",0]]);
// now loop through age bands in display order and pull out male/female totals
for (const age of order) {
// get the inner map for this age band, or an empty one if absent
const bySex = rolled.get(age) || new Map();
const m = +(bySex.get("Male") || 0);
const f = +(bySex.get("Female") || 0);
// update totals used for percentage calculations
total += (m+f);
totalBySex.set("Male", totalBySex.get("Male") + m);
totalBySex.set("Female", totalBySex.get("Female") + f);
male.push({ age, value: -m, abs: m });
female.push({ age, value: f, abs: f });
}
return { order, male, female, total, totalBySex };
}
{
const { order, male, female, total, totalBySex } = age_sex_pyramid_data;
const div = (this && this.style) ? this : DOM.element('div');
div.style.width = '100%';
div.style.height = '100%';
div.style.overflow = 'hidden';
// Early exit remains unchanged
const maxAbs = Math.max(
d3.max(male, d => d?.abs || 0) || 0,
d3.max(female, d => d?.abs || 0) || 0
);
if (!order.length || maxAbs === 0) {
div.innerHTML = "<div style='display:flex;align-items:center;justify-content:center;height:100%;color:#666;'><em>selection empty.</em></div>";
return div;
}
if (div.innerHTML.includes("selection empty")) div.innerHTML = "";
const niceMax = d3.ticks(0, maxAbs * 1.1, 5).slice(-1)[0] || maxAbs;
const posTicks = d3.ticks(0, niceMax, 5).slice(1);
const tickvals = [...posTicks.map(t => -t), 0, ...posTicks];
const ticktext = tickvals.map(v => d3.format(",d")(Math.abs(v)));
const mTotal = totalBySex.get("Male") || 0;
const fTotal = totalBySex.get("Female") || 0;
const mkCD = d => [d.abs, total ? (d.abs/total*100) : 0, mTotal ? (d.abs/mTotal*100) : 0];
const fkCD = d => [d.abs, total ? (d.abs/total*100) : 0, fTotal ? (d.abs/fTotal*100) : 0];
const displayLabel = age => age.replace('-', ' to ');
const maleTrace = {
y: order.map(displayLabel), x: male.map(d => d.value), customdata: male.map(mkCD),
name: "Male", type: "bar", orientation: "h", marker: { color: "#206095" },
hovertemplate: "%{y}<br>Male deaths: %{customdata[0]:,.0f}<br>Share of total: %{customdata[1]:.1f}%<br>Within male: %{customdata[2]:.1f}%<extra></extra>"
};
const femaleTrace = {
y: order.map(displayLabel), x: female.map(d => d.value), customdata: female.map(fkCD),
name: "Female", type: "bar", orientation: "h", marker: { color: "#00a3a6" },
hovertemplate: "%{y}<br>Female deaths: %{customdata[0]:,.0f}<br>Share of total: %{customdata[1]:.1f}%<br>Within female: %{customdata[2]:.1f}%<extra></extra>"
};
const rect = div.getBoundingClientRect();
const w = Math.floor(rect.width);
const h = Math.floor(rect.height);
const layout = {
title: { text: `<b>${selected_series} for ${selected_week_title} (Week ${selected_weeks})</b>`, x: 0, xanchor: 'left' },
barmode: "overlay",
xaxis: {
title: { text: 'Number of Deaths', font: { size: 16 } },
range: [-niceMax, niceMax],
tickvals, ticktext, zeroline: true, zerolinecolor: "#555", zerolinewidth: 1.5,
gridcolor: "rgba(0,0,0,0.06)"
},
yaxis: { title: "", categoryorder: "array", categoryarray: order.map(displayLabel), automargin: false },
legend: { orientation: "h", x: 0, y: 1.04, xanchor: 'right', yanchor: 'top' },
margin: { t: 40, b: 40, l: 120, r: 85 },
plot_bgcolor: "rgba(0,0,0,0)",
paper_bgcolor: "rgba(0,0,0,0)",
autosize: false, width: w, height: h
};
const config = { displayModeBar: false, responsive: true };
Plotly.react(div, [maleTrace, femaleTrace], layout, config);
if (!div._ro) {
div._ro = new ResizeObserver(entries => {
const cr = entries[0].contentRect;
Plotly.relayout(div, { width: Math.floor(cr.width), height: Math.floor(cr.height) });
});
div._ro.observe(div);
}
return div;
}